Skip to content
aviral gupta

// A4.2 · ~30 min · Advanced

Profiling with cProfile

After this lesson you can profile a program with cProfile, read ncalls, tottime and cumtime, sort and trim the report with pstats, and find and confirm the fix for the function that makes it slow.

Lesson 2 of 5 in A4 Performance and numbers

You will be able to

  • Profile code with cProfile and read ncalls, tottime and cumtime in the report
  • Sort and trim a report with pstats: sort_stats, print_stats and strip_dirs
  • Find the hot function and confirm a fix by its call count, not by a guess
  1. Warm-up · Activity 1 of 7

    Warm-up from the timeit lesson: a whole report takes 3 seconds, and you do not know why. Why is timeit alone not enough here?

  2. Predict · Activity 2 of 7

    Predict before you read on: fib calls itself. What does the profile record as its ncalls?

    import cProfile
    import pstats
    
    
    def fib(n):
        return n if n < 2 else fib(n - 1) + fib(n - 2)
    
    
    with cProfile.Profile() as profiler:
        fib(5)
    
    rows = pstats.Stats(profiler).get_stats_profile().func_profiles
    print(rows["fib"].ncalls)
  3. Practice · Activity 3 of 7

    outer calls light 50 times and heavy once; heavy runs a long loop. Fill in the key so that the function with the most time in its own code comes first.

    stats.sort_stats(SortKey.____)
    stats.sort_stats(SortKey.)
  4. Practice · Activity 4 of 7

    Here is a real report. Which function spends the most time in its own code, not counting the functions it calls?

             9005 function calls in 0.257 seconds
    
       Ordered by: cumulative time
       List reduced from 7 to 5 due to restriction <5>
    
       ncalls  tottime  percall  cumtime  percall filename:lineno(function)
            1    0.000    0.000    0.257    0.257 main.py:20(report)
            1    0.003    0.003    0.257    0.257 {built-in method builtins.sum}
         3001    0.016    0.000    0.254    0.000 main.py:21(<genexpr>)
         3000    0.005    0.000    0.239    0.000 main.py:16(line_total)
         3000    0.234    0.000    0.234    0.000 main.py:9(find_price)
  5. Practice · Activity 5 of 7

    Match each part of a profile row to its meaning.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. The list comprehension keeps the allowed items. How often does the profile say get_allowed was called?

    import cProfile
    import pstats
    
    
    def get_allowed():
        return {"a", "b"}
    
    
    items = ["a", "b", "c", "d"] * 25
    with cProfile.Profile() as profiler:
        kept = [x for x in items if x in get_allowed()]
    
    rows = pstats.Stats(profiler).get_stats_profile().func_profiles
    print(rows["get_allowed"].ncalls)
  7. Apply · Activity 7 of 7

    Mini-task, on your own computer. Take a script of yours that feels slow, or the worked example below. Run python -m cProfile -s cumulative script.py and note the most expensive high-level step. Then profile it with -s tottime and name the hot function. Fix it, profile again, and write down its ncalls and tottime before and after.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Finding the hot function in a report

report adds up 3000 orders; line_total looks each price up with find_price, which searches a list of 2000 pairs. The program profiles one run and reads the numbers with get_stats_profile() instead of printing the full table, because the seconds change on every run while the call counts and the winner do not. Run it on your computer, then add stats.print_stats(5) to see the table itself.

main.py

import cProfile
import pstats
from pstats import SortKey

PRICES = [(f"item{i}", i * 0.5) for i in range(2000)]


def find_price(name: str) -> float:
    for key, price in PRICES:  # a linear search: suspicious
        if key == name:
            return price
    raise KeyError(name)


def line_total(name: str, quantity: int) -> float:
    return find_price(name) * quantity


def report(orders: list[tuple[str, int]]) -> float:
    return sum(line_total(name, quantity) for name, quantity in orders)


orders = [(f"item{i % 2000}", 1 + i % 3) for i in range(3000)]

with cProfile.Profile() as profiler:
    total = report(orders)
print(f"total: {total:.1f}")

stats = pstats.Stats(profiler).strip_dirs().sort_stats(SortKey.TIME)
# stats.print_stats(5)  # the full table; its seconds differ on every run
profile = stats.get_stats_profile()
rows = profile.func_profiles

hottest = max(rows, key=lambda name: rows[name].tottime)
print("hottest:", hottest)
print("over half of all time:", rows[hottest].tottime > profile.total_tt / 2)
for name in ("report", "line_total", "find_price"):
    print(f"{name}: {rows[name].ncalls} calls")

Run it with

python main.py

Output

total: 2498500.0
hottest: find_price
over half of all time: True
report: 1 calls
line_total: 3000 calls
find_price: 3000 calls
  • find_price has the highest tottime: the loop over PRICES is where the work happens.
  • line_total and report have a high cumtime too, but only because find_price runs below them.
  • find_price runs 3000 times and scans up to 2000 pairs each time; a dict built once removes the scan.
  • ncalls is a string in get_stats_profile(), because a recursive function reports it as "15/1".

Exercises

Exercise 1 of 2

Counting calls with a profile

Complete profile_calls(func, *args). Run func(*args) inside with cProfile.Profile() as profiler:, then use pstats.Stats(profiler).get_stats_profile() and return a dict from each function name to its number of calls, as an int. For a recursive function, ncalls looks like "25/1": count the first number. Run the tests, then try it on your own functions on your computer.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Put func(*args) inside with cProfile.Profile() as profiler:, so only that call is recorded.

  2. Hint 2

    get_stats_profile().func_profiles maps each function name to an object with an ncalls attribute, a string.

  3. Hint 3

    int(row.ncalls.split("/")[0]) turns both "3" and "25/1" into a number.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import cProfile
import pstats
from collections.abc import Callable


def profile_calls(func: Callable[..., object], *args: object) -> dict[str, int]:
    with cProfile.Profile() as profiler:
        func(*args)
    profile = pstats.Stats(profiler).get_stats_profile()
    return {name: int(row.ncalls.split("/")[0]) for name, row in profile.func_profiles.items()}
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import cProfile
import pstats
from collections.abc import Callable


def profile_calls(func: Callable[..., object], *args: object) -> dict[str, int]:
    # Run func(*args) under cProfile.Profile, then return {function name: number of calls}.
    # A recursive function's ncalls looks like "15/1": count the first number.
    func(*args)
    return {}

test_main.py

from main import profile_calls


def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)


def helper(x):
    return x * 2


def three_helpers():
    return helper(1) + helper(2) + helper(3)


def test_counts_calls():
    """three_helpers runs once and calls helper 3 times"""
    calls = profile_calls(three_helpers)
    got = calls.get("three_helpers"), calls.get("helper")
    assert got == (1, 3), f"got {got!r} for three_helpers and helper, expected (1, 3)"


def test_recursion():
    """fib(6) makes 25 calls in all, counted from ncalls 25/1"""
    calls = profile_calls(fib, 6)
    assert calls.get("fib") == 25, f"calls['fib'] is {calls.get('fib')!r}, expected 25: take the number before the slash"


def test_values_are_ints():
    """Every count is an int"""
    calls = profile_calls(three_helpers)
    bad = {name: n for name, n in calls.items() if not isinstance(n, int)}
    assert calls and not bad, f"these counts are not ints: {bad!r}" if bad else "the dict is empty"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

Fixing the hot spot

count_known counts the words that appear in a vocabulary, ignoring case and spaces. On your computer, run python main.py: the profile, sorted by call count, shows normalise called hundreds of thousands of times. Fix count_known so that it normalises each vocabulary entry once, before the loop, and each word once. The answer must not change; a test profiles your version and counts the calls to normalise.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    The list comprehension inside the if runs again for every word: that is where the calls come from.

  2. Hint 2

    Build known = {normalise(v) for v in vocabulary} once, before the for loop.

  3. Hint 3

    Then the test is just normalise(word) in known, and a set lookup does not scan the vocabulary.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def normalise(word: str) -> str:
    return word.strip().lower()


def count_known(words: list[str], vocabulary: list[str]) -> int:
    known = {normalise(v) for v in vocabulary}
    count = 0
    for word in words:
        if normalise(word) in known:
            count += 1
    return count


if __name__ == "__main__":
    import cProfile

    words = ["Apple", "pear ", "kiwi", "PLUM"] * 500
    vocabulary = [f"fruit{i}" for i in range(300)] + ["apple", "pear", "plum"]
    cProfile.run("print(count_known(words, vocabulary))", sort="ncalls")
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def normalise(word: str) -> str:
    return word.strip().lower()


def count_known(words: list[str], vocabulary: list[str]) -> int:
    count = 0
    for word in words:
        if normalise(word) in [normalise(v) for v in vocabulary]:
            count += 1
    return count


if __name__ == "__main__":
    import cProfile

    words = ["Apple", "pear ", "kiwi", "PLUM"] * 500
    vocabulary = [f"fruit{i}" for i in range(300)] + ["apple", "pear", "plum"]
    cProfile.run("print(count_known(words, vocabulary))", sort="ncalls")

test_main.py

import cProfile
import pstats

from main import count_known

WORDS = ["Apple", "pear ", "kiwi", "PLUM"] * 50
VOCABULARY = [f"fruit{i}" for i in range(30)] + ["apple", " Pear", "plum"]


def normalise_calls(words, vocabulary):
    with cProfile.Profile() as profiler:
        result = count_known(words, vocabulary)
    rows = pstats.Stats(profiler).get_stats_profile().func_profiles
    return result, int(rows["normalise"].ncalls) if "normalise" in rows else 0


def test_same_answer():
    """150 of the 200 words are known"""
    got = count_known(WORDS, VOCABULARY)
    assert got == 150, f"count_known returned {got!r}, expected 150"


def test_normalises_each_word_once():
    """normalise runs once per word and once per vocabulary entry"""
    result, calls = normalise_calls(WORDS, VOCABULARY)
    expected = len(WORDS) + len(VOCABULARY)
    assert calls == expected, f"normalise was called {calls} times, expected {expected}: normalise the vocabulary once, before the loop"


def test_empty_words():
    """No words: the answer is 0"""
    got = count_known([], VOCABULARY)
    assert got == 0, f"count_known([], ...) returned {got!r}, expected 0"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

A sort key that does not exist

import cProfile
import pstats

with cProfile.Profile() as profiler:
    sum(range(1000))

pstats.Stats(profiler).sort_stats("speed").print_stats(5)

What Python prints

KeyError: 'speed'

Why, and the fix

sort_stats accepts only the documented keys, such as "calls", "cumulative", "tottime" and "name", or their SortKey members. An abbreviation works only when it is unambiguous: "cum" matches both "cumulative" and "cumtime" and fails the same way. Prefer SortKey.CUMULATIVE: mypy catches a misspelt enum member, but not a misspelt string.

Mixing up the string key and the enum name

import cProfile
import pstats
from pstats import SortKey

with cProfile.Profile() as profiler:
    sum(range(1000))

pstats.Stats(profiler).sort_stats(SortKey.TOTTIME).print_stats(5)

What Python prints

AttributeError: type object 'SortKey' has no attribute 'TOTTIME'

Why, and the fix

The column is called tottime, and the string "tottime" is a valid key, but the enum member for internal time is SortKey.TIME. Likewise "cumtime" has no member of its own: use SortKey.CUMULATIVE.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

A profile counts every call

cProfile records every function call and return, and how long each took. Profile a whole script with python -m cProfile -s cumulative script.py, a string with cProfile.run("main()"), or a block with with cProfile.Profile() as profiler:. The profiler slows Python code down more than C code, so use it to find where time goes and timeit to measure how fast something is. cProfile.run executes its string in the __main__ module, so the names it uses must exist there.

Reading the columns

ncalls is how often a function was called; 15/1 means 15 calls, of which 1 was not recursive. tottime is the time spent in the function’s own code, without the functions it called. cumtime includes everything it called. A high cumtime with a low tottime means "the time is below me"; the function where tottime is high is where the work happens. A surprising ncalls, such as a helper called once per item of a loop inside a loop, often points straight at the problem.

Sorting and trimming with pstats

pstats.Stats(profiler) turns the data into a report. strip_dirs() shortens file paths, sort_stats(SortKey.CUMULATIVE) or sort_stats("tottime") sets the order, and print_stats(10) prints only the first ten rows. Sort by cumulative time to see which high-level step is expensive, then by internal time to find the hot loop. get_stats_profile() gives the same numbers as objects, so a script or test can read a function’s ncalls instead of parsing text.

Sources

Last reviewed September 29, 2026