Profiling Python Code: Measure Before You Optimize
Find where the time actually goes with timeit and cProfile, then fix the slow part and measure again. After this, you stop optimizing code that was never the problem.
Course content
Sign in to save your progress.
-
Functions That Wrap Functions
-
Python Closures: Functions That Remember
-
Python Decorators in Real Code: Timing, Retry and Logging
-
-
-
Data That Doesn't Fit in Memory
-
Python Iterators: What a for Loop Actually Does
-
Python Generators and yield: One Piece at a Time
-
Python Context Managers: Clean Up Even When Things Fail
-
-
-
Catch Bugs Before You Run the Code
-
Python Type Hints Beyond the Basics: Optional, Union, Generics
-
Checking Types With mypy in a Real Project
-
-
-
Doing More Than One Thing at a Time
-
Python Concurrency Basics: Threads, Processes or asyncio?
-
Python asyncio: Wait for Many Things Without Waiting Longer
-
-
-
Ship It, Then Make It Fast
-
Packaging a Python Project With pyproject.toml
-
Profiling Python Code: Measure Before You Optimize
-
-
- Final Check: Python Advanced
Don't guess where your code is slow. Measure. Most of the time the slow part is not where you expected, and optimizing the part you suspected only makes that code harder to read while the program stays just as slow.
Skip ahead: If you already read cProfile output, know the difference between tottime and cumtime, and compare alternatives with timeit, take the section quiz.
First: is it actually too slow?
"Slow" needs a number and a reason. A nightly job that takes 40 seconds is fine. A web page that takes 4 seconds is not. Write down how long it takes now and how long it needs to take. If you can't say why the second number matters, stop here. Faster code that nobody needed is time you could have spent on something else.
The example
A report script that loads 20,000 orders, counts unique SKUs and totals the amounts. The data is generated with a fixed seed so the result is the same every run:
# report.py
import random
def load_orders(n):
rng = random.Random(42)
return [{"id": i, "sku": f"SKU-{rng.randint(1, 5000)}", "amount": rng.randint(100, 5000)}
for i in range(n)]
def unique_skus(orders):
seen = []
for order in orders:
if order["sku"] not in seen:
seen.append(order["sku"])
return seen
def total_amount(orders):
return sum(order["amount"] for order in orders)
def main():
orders = load_orders(20_000)
print(len(unique_skus(orders)), "unique SKUs")
print(total_amount(orders), "total")
if __name__ == "__main__":
main()
If you had to guess, you might pick the random data generation, since it makes 40,000 random numbers. Let's see.
Step 1: profile the whole program with cProfile
cProfile is in the standard library. It records every function call and how long it took:
import cProfile
import pstats
import report
cProfile.run("report.main()", "report.prof")
stats = pstats.Stats("report.prof")
stats.sort_stats("cumulative").print_stats("report.py")
4915 unique SKUs
51060787 total
...
ncalls tottime percall cumtime percall filename:lineno(function)
1 ...report.py:23(main)
1 ...report.py:11(unique_skus)
1 ...report.py:5(load_orders)
1 ...report.py:19(total_amount)
...
The numbers are replaced with "..." above because they differ on every computer. On the machine this course was tested on, the run took about half a second, and unique_skus was about 0.37 seconds of it, roughly three quarters of the total. load_orders, the one you might have guessed, took about 0.1 seconds. Run it and look at your own numbers; the ranking should be the same.
How to read the columns:
ncalls: how many times the function was called.tottime: time spent inside the function itself, not counting functions it called.cumtime: time including everything it called. Sorting by this shows which top-level step is expensive. Sorting bytottimeshows where the actual work happens.
You can also profile a script without changing it: python -m cProfile -s cumulative report.py. The output includes every function from every library, so it is longer; the print_stats("report.py") filter above keeps only your own code.
Step 2: understand why it is slow
order["sku"] not in seen checks a list. A list has no index, so in compares against every item, one by one. With about 5,000 unique SKUs, each of the 20,000 orders compares against up to 5,000 items. That is tens of millions of comparisons for a job that needs 20,000 lookups. A set finds an item directly, in about the same time no matter how big it is.
Step 3: compare the fix with timeit
timeit runs a small piece of code many times and reports the total, which smooths out random noise:
import timeit
from report import load_orders, unique_skus
def unique_skus_fast(orders):
seen = set()
result = []
for order in orders:
if order["sku"] not in seen:
seen.add(order["sku"])
result.append(order["sku"])
return result
orders = load_orders(20_000)
assert unique_skus_fast(orders) == unique_skus(orders) # same result, same order
slow = timeit.timeit(lambda: unique_skus(orders), number=5)
fast = timeit.timeit(lambda: unique_skus_fast(orders), number=5)
print(f"set version is {slow / fast:.0f}x faster")
set version is ...x faster
On the test machine, the set version was more than a hundred times faster. Your exact number will be different; the gap will still be huge. Notice the assert before the timing. A faster function that returns a different result isn't an optimization, it is a new bug. The fast version keeps a list too, so the SKUs stay in the order they were first seen, exactly like the original.
⚠ Warning: Always check that the optimized code gives the same output as the old code, on real data, before replacing it. Run your tests, and compare the outputs directly like the assert above. Optimizations are where subtle changes sneak in: a different order, rounding, a missing duplicate. They look like improvements until someone compares totals.
Step 4: profile again
After the fix, the profile looks different: load_orders is now the biggest part. Is it worth optimizing? It runs once and takes a fraction of a second. Probably not. This is where you stop. Optimizing has diminishing returns, and each step makes the code a little less obvious.
What usually matters
In everyday business code, the big wins are rarely clever micro-tricks. They are usually:
- The wrong data structure. Like the list lookup above.
inon a list inside a loop is the classic. - Doing the same work again and again. Reading a file or calling an API inside a loop when once would do.
functools.cachehelps when the same inputs repeat. - One database query per row. 10,000 small queries instead of one query that fetches 10,000 rows.
- Waiting. If the profile shows most time in network or disk calls, the fix is concurrency (previous section), not faster Python.
Common mistakes
- Optimizing without profiling. The point of this whole lesson.
- Measuring once. One run can be slowed by anything else on the computer.
timeitrepeats for that reason. - Profiling with tiny test data. With 50 orders, the list version is fast too. Profile with data the size of production, or the bottleneck doesn't show.
- Reading profiler times as exact. Profiling adds overhead to every call, so absolute times are inflated. Use them to compare functions, not as the real runtime.
💡 Tip: For a graphical view, the third-party tool snakeviz opens a .prof file in the browser as a chart, and py-spy can profile a program that is already running without changing it. Start with cProfile; reach for those when the text output gets hard to read.
What happens if you skip this step
You spend a day rewriting the part you thought was slow, the code gets harder to read, and the program is exactly as slow as before, because the real problem was a list lookup in a function you never looked at. Ten minutes of profiling would have pointed straight at it.
What is the slowest thing your code does today, and are you guessing, or did you measure it?
About
Find where the time actually goes with timeit and cProfile, then fix the slow part and measure again. After this, you stop optimizing code that was never the problem.
- Duration
- 10 min
- Last update
- 09/11/2026
Comments (0)
Sign in to commentNo comments yet.