Reading Flame Graphs for Python Services

A flame graph is a summary of thousands of stacks drawn so that identical prefixes merge. Almost everything people get wrong about it comes from reading the vertical axis as though it meant something. This page covers the rules, the shapes that recur specifically in Python services, and what each one implies about where a change belongs. It is a task article under CPU profiling Python services, part of the Python profiling and performance observability section.

Four shapes and what each one means Four flame graph fragments are drawn side by side. The plateau is a single wide bar with a flat top, meaning a function doing its own work, and the fix is inside that function. The comb is a wide bar with many narrow towers rising from it, meaning the cost is call volume from many call sites, and the fix is upstream where the calls originate. The tower is a tall narrow stack, which despite its visual prominence accounts for almost nothing and should be ignored. The floor is a set of full-width bars at the base, present in every stack because every request passes through them, which are framework machinery rather than cost and should be collapsed. Beneath each shape is where a change belongs. the four shapes that recur in Python profiles plateau does its own work fix inside it comb called from everywhere fix upstream tower deep and free ignore it floor in every stack collapse it the reading rule, in one sentence scan from the base for the widest bar you own, then read the shape above it to decide whether the fix is inside or upstream height never enters into it
Four shapes cover most of what a Python profile contains, and each one points at a different place for the change to go.

Prerequisites

pip install "py-spy>=0.3.14,<0.5.0"
# a profile to read
py-spy record --pid 1 --duration 60 --format speedscope --output profile.json

Implementation

Step 1 — Fix the axes in your head before looking. The horizontal extent of a bar is the fraction of samples in which that frame was on the stack, which is proportional to time. The vertical position is how deep the frame sits in the call chain, which is proportional to nothing. Bars are also usually sorted alphabetically rather than chronologically, so horizontal position carries no time ordering — a flame graph is not a timeline, and reading it left to right as a sequence of events is a misreading that produces confident nonsense.

Step 2 — Find and collapse the floor. In a web service, every stack begins with the same twenty or thirty frames: the server's accept loop, the application dispatcher, the middleware chain, the router, the view dispatcher. They span the full width because they are in every sample, and they contain no cost of their own. Most viewers can hide or merge them, and doing so is the difference between a graph dominated by machinery and one showing the application's own shape.

Step 3 — Scan upward for the widest bar you own. From the top of the floor, look for the widest frame implemented in your codebase or in a library you can influence. Frames below it are its callers and frames above it are its callees; the one you have found is where the cost concentrates at a level you can act on.

Step 4 — Read the shape above that bar. This decides where the fix goes, and there are two cases. A flat top means the function is executing its own code, so the change is inside it: a better algorithm, an avoided allocation, a cached result. Many narrow towers above it means it is a dispatcher, and its cost is the volume of calls arriving from elsewhere — the change belongs in whatever is calling it so many times.

Step 5 — Check which kind of profile you are holding. An on-CPU graph shows execution; a wall-clock graph shows elapsed time including blocking. The same visual — a wide bar in a database driver — means "this driver is burning cycles" in the first and "this query is slow" in the second, and those lead to entirely different work. The profile's own metadata says which it is, and it is worth confirming rather than assuming.

Step 6 — Diff rather than read. An absolute percentage is hard to interpret; a change in a percentage is not. Most viewers support a differential view, and even without one, a folded-stack comparison is a few lines of script.

# fold two speedscope profiles down to frame totals and compare
import collections, json, sys

def totals(path):
    doc = json.load(open(path))
    frames = doc["shared"]["frames"]
    counts = collections.Counter()
    for prof in doc["profiles"]:
        for frame_index, weight in zip(prof["samples"], prof["weights"]):
            leaf = frame_index[-1] if frame_index else None
            if leaf is not None:
                counts[frames[leaf]["name"]] += weight
    total = sum(counts.values()) or 1
    return {name: n / total for name, n in counts.items()}

old, new = totals(sys.argv[1]), totals(sys.argv[2])
rows = [(new.get(k, 0) - old.get(k, 0), k) for k in set(old) | set(new)]
for delta, name in sorted(rows, reverse=True)[:6]:
    print(f"{delta:+7.2%}  {name}")

Expected Output: one frame accounting for a regression, which is the usual shape.

 +6.02%  parse
 +0.44%  to_representation
 -0.31%  execute
 -0.77%  loads
The same bar, two meanings Two flame graphs of the same sixty second window are drawn. In the on-CPU graph a database driver frame is narrow and a serialisation frame is wide, so the process spends its executing time serialising. In the wall-clock graph the same database driver frame spans most of the width, because the thread was blocked inside it waiting for a result, and the serialisation frame is a small sliver by comparison. The identical visual element — a wide bar labelled with the driver — means the driver is burning processor time in one graph and that a query is slow in the other. The note records that the profile's metadata states which kind it is, and that acting on the wrong interpretation sends an engineer to optimise a driver that is doing nothing at all. the same window, two profiles on CPU framework floor serialize — wide, actually executing driver — narrow wall clock framework floor driver — wide, and blocked the whole time identical visual, opposite conclusions on CPU: this code burns cycles · wall clock: this call waits · the metadata says which graph you have reading the second as the first sends somebody to optimise a driver that is doing nothing
The single most consequential question about a flame graph is which kind it is, and it is answered by metadata rather than by looking.

Shapes that are specific to Python

Several recurring patterns are artefacts of the interpreter or of common Python libraries rather than of the application, and recognising them saves time.

A wide <module> frame means you profiled a startup. Module-level code executes at import, so a profile taken in the first seconds of a process is dominated by it. This is not a finding about the service; it is a finding about when the profile was taken.

<listcomp>, <genexpr> and <lambda> appear as their own frames. They are separate code objects, so a comprehension inside a hot function shows up one level above it. This is useful — it localises the cost within a function — and occasionally confusing when the same name appears in several places.

A wide __getattr__ or descriptor frame means attribute access is the cost. Common in ORMs and serialisation libraries, where every field access goes through a descriptor. The fix is almost never in the descriptor; it is in the number of attribute accesses, which usually means fetching or serialising fields nobody needs.

Garbage collection appears as its own frames, attributed to whoever triggered it. A collection runs on the thread that allocated past the threshold, so its cost lands in an unrelated function that happened to allocate at the wrong moment. A meaningful gc share across many callers is a signal about allocation rate rather than about any of those callers, and it connects to monitoring Python garbage collection and memory usage.

Under asyncio, everything hangs off the loop's frames. run_once, _run, and the task's step function sit between the loop and every coroutine, so the application's structure is fragmented beneath them. Collapsing those frames, or using a profiler that groups by task, restores a readable shape.

What a flame graph cannot tell you

Three questions come up constantly in front of a flame graph and none of them is answerable from one, which is worth knowing before an hour is spent trying.

Whether the cost is one slow call or many fast ones. A frame occupying twenty percent of samples might be one call taking twelve seconds or twelve thousand calls taking a millisecond each. The graph is identical either way. Answering it requires call counts, which means a deterministic profile as described in using cProfile and pstats in production, or a span around the operation.

Which request the cost belongs to. Samples are attributed to frames, not to requests, so a profile of a process serving six endpoints mixes all six. If one endpoint is the problem and it is five percent of traffic, it contributes five percent of the samples no matter how slow it is. Recovering per-request attribution needs the span context recorded alongside each sample.

Whether it got worse. A single graph has no time dimension. Everything about regression detection requires two profiles, which is why storing them matters more than rendering them nicely. A profile that exists only as a picture somebody looked at once has answered a question and left nothing behind.

Reading a flame graph in four passes Four passes for reading a CPU flame graph of a Python service. First, look at the top edge for the widest plateaus: functions where samples ended, meaning time was spent in them directly. Second, follow each plateau down to find which of your own functions called it, since the plateau is often inside a library. Third, compare widths with a baseline profile from before the change, looking for frames that grew. Fourth, check for framework and interpreter frames that should be narrow, such as serialisation or logging, being wide. The note says width is the only dimension that measures time. four passes over one flame graph 1 · top edge widest plateaus = self time 2 · walk down which of your functions called it 3 · compare against a baseline look for growth 4 · surprises wide logging or serialisation frames width is the only dimension that measures time
Start at the top edge, walk down to your own code, then compare with a baseline. Height and colour carry no timing.

Configuration options

Reading step What to do Why
Axes width is time, height is depth the format's most common misreading
Ordering not chronological a flame graph is not a timeline
Framework floor collapse or hide it is present in every stack
Widest owned frame scan upward from the floor the level you can act on
Shape above it flat means inside, towers mean upstream decides where the fix goes
Profile kind check the metadata on-CPU and wall-clock differ completely
Comparison diff against a baseline absolutes are hard to interpret

Verification

Confirm your reading by predicting something and checking it. If a frame is claimed to be a third of the profile, an independent measurement should agree.

# how much of the profile is one frame, computed directly
import collections, json
doc = json.load(open("profile.json"))
frames = doc["shared"]["frames"]
counts = collections.Counter()
for prof in doc["profiles"]:
    for stack, weight in zip(prof["samples"], prof["weights"]):
        for idx in set(stack):
            counts[frames[idx]["name"]] += weight
total = sum(prof["weights"][0] for prof in doc["profiles"]) or 1
print(f"parse appears in {counts['parse'] / sum(doc['profiles'][0]['weights']):.1%} of samples")

Expected Output: a figure matching what the graph appeared to show.

parse appears in 31.4% of samples

A number substantially different from the visual impression usually means the frame appears at several depths and the eye merged only one of them, which is a good reason to check rather than to eyeball.

Common mistakes

Reading height as cost. Error signature: an optimisation aimed at a deep call chain that changes nothing. Root cause: the vertical axis is call nesting. Remediation: rank by width only.

Reading left to right as a sequence. Error signature: a conclusion about what happened before what. Root cause: bars are usually ordered alphabetically, not chronologically. Remediation: treat the graph as an aggregate, and use a trace when ordering matters.

Acting on a framework frame. Error signature: a plan to optimise the request dispatcher. Root cause: the floor spans the full width because it is in every stack. Remediation: collapse the floor and read above it.

Not knowing which profile kind you have. Error signature: optimising a database driver that is doing nothing. Root cause: a wall-clock graph read as on-CPU. Remediation: check the metadata before interpreting.

Profiling a cold process. Error signature: a graph dominated by imports and module-level code. Root cause: the profile was taken during startup. Remediation: profile a warm process under load.

Frequently Asked Questions

What does the height of a flame graph mean?

Call depth, and nothing else. A twenty-frame tower may account for two samples out of three thousand. Only the horizontal extent carries cost information, which is the single most common misreading of the format.

Why is the bottom of my graph all framework code?

Because every request passes through the same middleware, routing and view dispatch, so those frames are present in every stack and therefore span the full width. They are a floor rather than a cost; collapse or hide them and read what sits above.

What does a wide frame with many narrow towers above it mean?

That the frame is called from many places or many times, and the cost is the call volume rather than the function. The fix is upstream, in whatever is producing the calls, not in the function itself.

Should I read a flame graph or an icicle graph?

They are the same data drawn in opposite directions. Icicle graphs, growing downward from the root, are easier to read for deep stacks because the root is at a fixed position. The interpretation rules are identical.