Python profiling is how you find the slow parts of your code with evidence instead of a hunch. This guide shows you the three tools that matter, cProfile for the big picture, timeit for tiny measurements, and py-spy for a program that is already running, then walks you through reading a flame graph and fixing the hotspots for a real, measured speedup.
“Measure. Do not tune for speed until you have measured, and even then do not unless one part of the code overwhelms the rest.”
Rob Pike, Notes on Programming in C
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Advanced | Reading Time: 18 minutes
Almost every developer optimizes the wrong thing at least once. You feel that a function is slow, you spend an afternoon rewriting it, and the program runs exactly as slow as before because the real cost was hiding somewhere you never looked. The lesson is boring but it is the whole game: measure first, then fix what the measurement points at. Guessing is not engineering; Python profiling is.
Think of it like a plumber finding a leak. A good plumber does not rip open every wall in the house. They run the water, watch the meter, and follow the drip to the one joint that is actually failing. A profiler is that meter for your code. It watches your program run and tells you exactly which lines drank the most time, so you fix the one joint that matters and leave the rest alone.
Say a developer named Aviraj has a nightly report that takes six minutes and his manager wants it under one. He could rewrite the whole thing on instinct. Instead he profiles it, sees that ninety percent of the time is spent in one lookup function, fixes that single function, and the report finishes in seconds. He touched five lines. That is the difference a profiler makes.
The diagram above is a flame graph, the single most useful picture in performance work, and we will read it properly in a few minutes. For now, notice the one rule that trips everyone up: width means time, not importance and not depth. The widest boxes are where your program lives. Everything narrow is noise you can safely ignore.
Table of Contents
Prerequisites
This is an advanced post, so it helps to have the fundamentals behind you. You should be comfortable with functions, dictionaries and sets, and the idea of algorithmic cost from the time complexity tutorial, because the biggest speedups almost always come from a better algorithm, not a faster line. cProfile, timeit, and tracemalloc all ship with Python, so most of the Python profiling in this post needs nothing installed. The py-spy and Scalene sections use external tools you install with pip, and I will call that out where it happens.
Rule Zero: Measure, Never Guess
Here is a script that is slow on purpose, and slow in the two most common ways real code is slow: it scans a list to find things, and it redoes the same parsing work over and over inside a loop. We wrap it in cProfile, the profiler built into Python, which counts how many times each function runs and how long each one takes.
📄 slow.py: a report builder profiled with cProfile
import cProfile, pstats, io
customers = [{"id": i, "name": f"user{i}"} for i in range(2000)]
orders = [{"customer_id": i % 2000, "amount": i} for i in range(6000)]
def parse_config():
# pretend this reads and parses a file on every call (wasteful work)
text = "rate=0.18\ncurrency=INR\n" * 50
d = {}
for line in text.strip().split("\n"):
k, v = line.split("=")
d[k] = v
return d
def find_customer(cid):
for c in customers: # linear scan, O(n) every time
if c["id"] == cid:
return c
return None
def build_report():
total = 0
for o in orders:
cfg = parse_config() # re-parsed 6000 times
cust = find_customer(o["customer_id"]) # linear scan 6000 times
total += o["amount"] * float(cfg["rate"])
return total
if __name__ == "__main__":
profiler = cProfile.Profile()
profiler.enable()
result = build_report()
profiler.disable()
print(f"Report total: {result:.2f}")
s = io.StringIO()
pstats.Stats(profiler, stream=s).sort_stats("cumulative").print_stats(6)
print(s.getvalue())
▶ Output
Report total: 3239460.00
624002 function calls in 0.614 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.012 0.012 0.614 0.614 slow.py:23(build_report)
6000 0.188 0.000 0.354 0.000 slow.py:8(parse_config)
6000 0.247 0.000 0.247 0.000 slow.py:17(find_customer)
606000 0.163 0.000 0.163 0.000 {method 'split' of 'str' objects}
6000 0.003 0.000 0.003 0.000 {method 'strip' of 'str' objects}
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
What happened here: read the table one column at a time. ncalls is how many times the function ran, and two numbers jump out: find_customer and parse_config each ran 6000 times, and str.split ran 606000 times. tottime is the time spent inside that function alone, not counting the functions it calls. cumtime is the total including everything it called. Sort by cumtime and the story is obvious: find_customer burned 0.247 seconds and parse_config burned 0.354 seconds, and together they are basically the entire 0.614 second runtime. You did not have to guess. The profiler pointed straight at the two functions worth fixing and let you ignore everything else. That is the whole reason the tool exists.
You do not even need to edit your code to do this. For a quick pass on any script, run it straight from the command line and Python profiles the whole thing for you.
📄 Terminal: profile any script without touching its code
# -s tottime sorts by time spent inside each function python -m cProfile -s tottime slow.py
timeit for Micro, cProfile for Macro, py-spy for Live
These three Python profiling tools answer three different questions, and picking the wrong one wastes your afternoon. Think of them like measuring instruments in a kitchen. timeit is a precise scale for weighing one small thing many times. cProfile is a full inventory of everything happening in the whole meal. py-spy is a thermal camera you point at a stove that is already on without turning it off.
Use timeit when you have one small snippet and want a trustworthy number for it. It runs the code many times and averages, which cancels out the random noise a single run would give you. Here it settles the oldest performance question in Python: is checking membership in a set really faster than in a list?
📄 timeit_demo.py: is set lookup really faster than list?
import timeit
setup = "data_list = list(range(100000)); data_set = set(data_list); target = 99999"
list_time = timeit.timeit("target in data_list", setup=setup, number=1000)
set_time = timeit.timeit("target in data_set", setup=setup, number=1000)
print(f"list membership: {list_time:.4f} s for 1000 runs")
print(f"set membership: {set_time:.6f} s for 1000 runs")
print(f"set is about {list_time / set_time:.0f}x faster here")
▶ Output (timings vary by machine)
list membership: 1.1275 s for 1000 runs set membership: 0.000037 s for 1000 runs set is about 30805x faster here
What happened here: a list has to walk item by item until it finds the target, so looking for the last element in a hundred thousand item list checks all hundred thousand. A set uses a hash table, which jumps more or less straight to the answer no matter how big it gets. The measured gap is enormous because we deliberately searched for the worst case, the very last element. This is not a micro-detail, it is the single most common Python speedup there is, and we will use it in the fix section next.
The third tool is for a different emergency. Your service is running slow in production right now, you cannot stop it, and you cannot add profiling code and redeploy. This is where a sampling profiler earns its keep. At the time of writing, py-spy is the popular choice, and Scalene is a strong alternative that profiles Central Processing Unit (CPU) and memory together. Both attach to a process that is already running, peek at its call stack thousands of times a second, and build a picture from those samples without you changing a single line.
📄 Terminal: sample a live Python process by its PID
pip install py-spy # Find the process id, then watch it live like a top for Python py-spy top --pid 48231 # Or record for 30 seconds and write an interactive flame graph py-spy record --pid 48231 --duration 30 --output profile.svg
▶ Example output (py-spy top attached to a live worker)
Total Samples 8400 GIL: 71.00%, Active: 96.00%, Threads: 4 %Own %Total OwnTime TotalTime Function (filename) 58.00% 58.00% 4.88s 4.88s find_customer (worker.py:17) 31.00% 89.00% 2.61s 7.49s build_report (worker.py:23) 9.00% 40.00% 0.76s 3.36s parse_config (worker.py:8) 2.00% 2.00% 0.17s 0.17s <method 'split' of 'str'>
What happened here: this output is representative, since attaching to a live process needs a real running server and, on some systems, admin rights, so I have shown the shape you will see rather than a capture from this page. The %Own column is the fraction of samples caught inside that function itself, and find_customer owns most of them, the same culprit cProfile found offline. That is the point of a sampling profiler: near zero slowdown on the running program, and you still get the same answer. The tradeoff is that it samples rather than counts, so very fast functions can be under-reported. For an always-on service, that tradeoff is almost always worth it.
How to Read a Flame Graph
When py-spy record finishes, it hands you a flame graph, and this is where people freeze because it looks complicated. It is not. A flame graph is just a stack of boxes. Each box is a function. A box sits on top of the box that called it, so height shows how deep the call chain goes. The one thing that actually matters is width. Width is time. A wide box ate a lot of your program’s runtime, a narrow box ate almost none.
Reading one is a two second habit once it clicks. Ignore the tall skinny towers, they are just deep call chains doing cheap work. Look for the widest boxes, especially a wide flat plateau near the top of the stack. That plateau is a function that is both being called a lot and doing real work each time, and it is almost always your hotspot. In the graph earlier in this post, find_customer and parse_config are the wide boxes. The tools all agree, and they are telling you exactly where to spend your one afternoon.
Fix the Hotspots: The 500x Payoff
Now the fun part. The profiler named two hotspots, so we fix exactly those two and nothing else. There are three classic moves, and this one script needs all three. First, an algorithmic fix: replace the linear find_customer scan with a dictionary built once, turning an O(n) lookup into an O(1) one, the exact idea from the time complexity tutorial. Second, caching: parse_config returns the same thing every time, so we wrap it in functools.lru_cache and it runs once instead of 6000 times. Third, hoisting: work that does not change between iterations moves out of the loop entirely.
📄 fixed.py: three fixes, then a head-to-head timing
import time
from functools import lru_cache
customers = [{"id": i, "name": f"user{i}"} for i in range(2000)]
orders = [{"customer_id": i % 2000, "amount": i} for i in range(6000)]
# Fix 1: build an index once, O(1) lookups instead of O(n) scans
customer_by_id = {c["id"]: c for c in customers}
# Fix 2: cache the parse so the work happens once, not 6000 times
@lru_cache(maxsize=1)
def parse_config():
text = "rate=0.18\ncurrency=INR\n" * 50
d = {}
for line in text.strip().split("\n"):
k, v = line.split("=")
d[k] = v
return d
def build_report_fast():
cfg = parse_config()
rate = float(cfg["rate"]) # Fix 3: hoist constant work out of the loop
total = 0
for o in orders:
cust = customer_by_id[o["customer_id"]] # dict lookup, O(1)
total += o["amount"] * rate
return total
def bench(fn, n=5):
start = time.perf_counter()
for _ in range(n):
fn()
return (time.perf_counter() - start) / n * 1000
▶ Output (before is the original slow.py, after is fixed.py)
Before: 366.91 ms per run After: 0.658 ms per run Speedup: 558x
What happened here: the report went from 367 milliseconds to under one, more than 500 times faster, and we did not rewrite the program or reach for a faster language. We changed a list scan into a dictionary lookup, cached one function, and moved one line out of a loop. Your own wins will usually land somewhere between 10x and 100x, and once in a while you hit one like this. The point is not the exact number. The point is that we knew where to aim because we profiled first. Without the profile, most people would have “optimized” the arithmetic in the loop, which cProfile showed costs almost nothing, and gone home puzzled that it made no difference.
Memory Profiling with tracemalloc
Speed is only half of the Python profiling story. The other half is memory, and a program that quietly grows until it gets killed is its own kind of slow. Python ships with tracemalloc for exactly this. It records where every object was allocated, so instead of guessing which function is eating your RAM, you get a ranked list of the actual lines. Think of it as an itemized receipt for memory: not “you spent a lot,” but “here are the three purchases that cost the most.”
📄 mem.py: find the lines that allocate the most
import tracemalloc
def build_wasteful():
rows = []
for i in range(100000):
rows.append([i, i * 2, str(i), {"idx": i}]) # four objects per row
return rows
tracemalloc.start()
data = build_wasteful()
current, peak = tracemalloc.get_traced_memory()
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()
print(f"Current memory: {current / 1024 / 1024:.1f} MB")
print(f"Peak memory: {peak / 1024 / 1024:.1f} MB")
print("Top 3 allocation sites:")
for stat in snapshot.statistics("lineno")[:3]:
print(" ", stat)
▶ Output
Current memory: 37.2 MB Peak memory: 37.2 MB Top 3 allocation sites: mem.py:7: size=34.1 MiB, count=599828, average=60 B mem.py:6: size=3117 KiB, count=99743, average=32 B mem.py:11: size=400 B, count=1, average=400 B
What happened here: tracemalloc points straight at line 7, the row we append, which allocated 34 megabytes across roughly 600000 objects. That count is the giveaway: four objects per row times a hundred thousand rows is the memory. Once you can see the guilty line, the fixes suggest themselves. Store tuples instead of lists, skip the per-row dictionary, or do not hold all hundred thousand rows in memory at once and stream them with a generator instead. As with speed, you measure first, then you know which line is worth the change.
When the Answer Is NumPy
Sometimes you profile, you fix the obvious hotspots, and the wide box is still a plain Python loop doing arithmetic over millions of numbers. There is a ceiling on how fast a Python for loop can crunch numbers, because every single step pays the cost of Python being a flexible, interpreted language. When your hotspot is number crunching, the real fix is not a cleverer loop, it is no loop at all. You hand the whole array to NumPy, which runs the operation in fast compiled code under the hood. This is called vectorization.
📄 vec.py: a Python loop versus a NumPy vector
import time
import numpy as np
n = 2_000_000
py_list = list(range(n))
start = time.perf_counter()
total = 0
for x in py_list:
total += x * x
py_time = time.perf_counter() - start
arr = np.arange(n)
start = time.perf_counter()
total_np = int((arr * arr).sum()) # one vectorized operation, no Python loop
np_time = time.perf_counter() - start
print(f"Pure Python loop: {py_time*1000:.1f} ms")
print(f"NumPy vectorized: {np_time*1000:.1f} ms")
print(f"Speedup: {py_time/np_time:.0f}x (same answer: {total == total_np})")
▶ Output
Pure Python loop: 274.7 ms NumPy vectorized: 5.5 ms Speedup: 50x (same answer: True)
What happened here: both versions compute the same sum of squares and agree on the answer, but NumPy did it 50 times faster by pushing the entire calculation into compiled code and skipping two million trips through the Python interpreter. This is the moment your profiler is really telling you to change tools, not tune the loop. If your slow code is heavy numeric work over big arrays, the NumPy tutorial is where you go next, and it will beat any hand-tuned Python loop you can write.
Common Mistakes
- Optimizing before profiling. The single most common mistake. You are almost always wrong about where the time goes. Measure, then act.
- Timing with a single run. One run is full of noise from the operating system and other processes. Use
timeit, which runs the snippet many times and averages. - Confusing tottime and cumtime.
tottimeis time inside the function alone.cumtimeincludes everything it called. A function with tiny tottime but huge cumtime is not the hotspot, its children are. - Forgetting cProfile adds overhead. The profiler itself slows your program, so treat the absolute numbers as relative. For true wall-clock timing use
time.perf_counterortimeit. - Reading a flame graph by height. Height is call depth and it does not matter. Width is time and it is the only thing that does.
- Micro-tuning a loop that should be NumPy. If the hotspot is arithmetic over millions of values, no loop tweak beats vectorization.
Best Practices
- Profile with realistic data. A hotspot on ten rows can be invisible, and a function that is fine on ten rows can dominate on ten million.
- Fix one hotspot, then profile again. The second biggest problem becomes the biggest the moment you remove the first, and the picture shifts.
- Reach for a better algorithm or data structure before clever tricks. A dictionary instead of a list scan beats any amount of line-level tuning.
- Use
cProfilein development and a sampling profiler like py-spy in production, since it barely slows the running service. - Keep a saved timing so you can prove the change worked. “Feels faster” is not a number your reviewer can trust.
Conclusion
Python profiling turns performance from guesswork into a short, honest process. Reach for cProfile to see the whole program, timeit to weigh a single snippet, and a sampling profiler like py-spy or Scalene when the code is already running and you cannot stop it. Read the flame graph by width, fix the widest box with a better algorithm, a cache, or vectorization, and then measure again to prove it. We took one deliberately slow script from 367 milliseconds to under one, more than 500 times faster, by changing five lines the profiler told us to change.
That is the habit worth keeping: measure, fix what the measurement points at, measure again. For the full roadmap and every other tutorial, the Python + AI/ML tutorial series home has you covered.
Frequently Asked Questions
What is the difference between cProfile and timeit?
cProfile profiles a whole program and shows how long every function took, so you use it to find which function is slow. timeit measures one small snippet very precisely by running it many times, so you use it to compare two ways of writing the same small thing. Use cProfile for the big picture and timeit for a single line.
What does tottime mean versus cumtime in cProfile output?
tottime is the time spent inside that function alone, not counting the functions it calls. cumtime is the cumulative total that also includes everything the function called. Sort by cumtime to find the most expensive call path, and by tottime to find the function doing the most work itself.
How do I profile a Python program that is already running?
For live Python profiling, use a sampling profiler that attaches to the process by its process id. At the time of writing, py-spy and Scalene are the common choices. They peek at the call stack thousands of times a second without stopping or slowing the program, which makes them safe for production.
How do I read a flame graph?
Width means time and height means call depth. Ignore tall thin towers and look for the widest boxes, especially a wide plateau near the top of the stack. That plateau is the function eating most of your runtime and the first thing to optimize.
When should I use NumPy instead of optimizing a loop?
When your hotspot is numeric work over large arrays. A Python for loop pays interpreter overhead on every element, while NumPy runs the whole operation in compiled code. If the profiler keeps pointing at a numeric loop, vectorize it with NumPy rather than tuning the loop.
Interview Questions on Python Profiling
If you can walk through these without peeking, you are ready for this topic in an interview.
Performance and profiling come up constantly in systems and backend interviews at companies like Microsoft, NVIDIA, and Databricks, because they separate people who guess from people who measure. Here are the questions that show up most.
Q: A function has a small tottime but a very large cumtime in cProfile. Where is the time actually going?
Into the functions it calls, not into the function itself. tottime counts only the work done directly inside the function, while cumtime adds everything its children did. A large cumtime with a tiny tottime is a coordinator that mostly delegates, so the real hotspot is one of the functions further down the call chain. You follow the cumtime down until you reach a function whose own tottime is large, and that is the one to optimize.
Q: Why would you use a sampling profiler like py-spy in production instead of cProfile?
cProfile instruments every function call, which adds real overhead and requires wrapping or restarting the program, neither of which is acceptable on a live service. A sampling profiler attaches to the running process and reads its stack a few thousand times a second, so the slowdown is negligible and you never stop the service. The tradeoff is precision: sampling can under-report very short functions, but for finding the big hotspots on a running system it is exactly the right tool.
Q: You profiled and the widest box is a Python loop summing over ten million floats. What is your move?
Stop tuning the loop and vectorize it. Every iteration of a pure Python loop pays interpreter overhead that dwarfs the actual arithmetic, so no amount of line-level cleverness gets you far. Moving the whole computation into NumPy runs it in compiled code over a contiguous array and typically buys a 10x to 100x speedup for numeric work. The profiler pointing at a numeric loop is the signal to change tools, not to micro-optimize.
Q: How would you find a memory problem rather than a speed problem?
Use tracemalloc from the standard library. You call tracemalloc.start(), run the suspect code, take a snapshot, and print the top allocation sites, which gives you a ranked list of the exact lines that allocated the most memory. For a live process, a memory-aware sampling profiler such as Scalene reports CPU and memory together. Either way the principle matches speed work: measure to find the guilty line, then fix that line specifically.
Q: Your single timing run says a change made the code faster, but a teammate is skeptical. How do you convince them?
A single run is unreliable because background processes and the operating system add noise, so one number proves nothing. Re-run the benchmark with timeit or a loop around time.perf_counter that averages many runs, and report the before and after with the same data on the same machine. A repeatable averaged measurement, ideally with the profile that identified the hotspot, turns “feels faster” into evidence anyone can reproduce.
Related Posts
Previous: Python: Security Basics, Secrets, Input Validation, Dependency Scanning
Next: FastAPI Capstone: Build, Test, and Ship a Real Application Programming Interface (API)
Series Home: Python + AI/ML Tutorial Series

No comment